Extension Hooks
pxf runs a small set of extension hooks after each apply and after each disable. The mechanism is inspired by csfpost.sh in CSF and cron.d/sudoers.d in the OS: third-party packages drop a script into a well-known directory, and pxf invokes it with a stable environment.
This page documents the contract between pxf and hook authors.
Why hooks exist
pxf is a declarative firewall manager: every apply flushes the kernel and rebuilds the rule set from the journal. That model is great for predictability, but it conflicts with services that need to keep custom rules in place across applies (e.g. pxShield redirecting 80 → 19080 and 443 → 19443 to its WAF).
Rather than hard-coding awareness of each integration into pxf, the hook system gives those services a generic extension point. pxf stays agnostic of who is using it; the integration logic lives entirely on the consumer side.
Hook directories
| Directory | Triggered by |
|---|---|
/etc/pxf/post-apply.d/ | End of pxf apply (after the journal snapshot has been applied). |
/etc/pxf/post-disable.d/ | End of pxf disable (after the firewall has been flushed and set to ALLOW ALL). Skipped when --no-hooks is passed. |
Both directories are created with mode 0755, owned by root:root, during pxf installation.
A hook is any executable file under one of those directories whose name ends in .sh. Other entries (no extension, .disabled, .bak, subdirectories…) are ignored. Files are sorted alphabetically before execution; the convention is NN-name.sh where NN is a two-digit prefix (e.g. 50-pxshield.sh).
Environment variables
When pxf invokes a hook it adds the following environment variables on top of the inherited environment:
| Variable | Possible values | Notes |
|---|---|---|
PXF_EVENT | apply, disable | The lifecycle event that triggered the hook. |
PXF_BACKEND | nftables, iptables-legacy, iptables-nft | Active firewall backend. May be empty when the backend cannot be determined; hooks should treat the empty string as "do nothing". |
PXF_VERSION | e.g. 1.5.0 | Version of pxf invoking the hook. |
Hooks should branch on PXF_BACKEND to choose between nft and iptables/ip6tables syntax.
Security model
pxf hooks run as root. They cannot be sandboxed in a meaningful way without losing the ability to manage netfilter rules. The threat model assumes that anyone who can write into the hook directories already has root, so the security goal is to make sure no non-root path can place or modify a hook.
Before invoking a hook, pxf validates it. If any check fails, the hook is skipped with a warning and pxf continues with the next one. The validations are:
- Owner: the file (and the parent directory) must be owned by
root:root. - Permissions: the file (and the parent directory) must not be writable by group or other (i.e.
mode & 0o022 == 0). Typical mode is0755. - No symlinks: neither the hook file nor its directory may be a symlink. This prevents redirect attacks via writable parent directories.
- Regular file: the hook entry must be a regular file (no FIFOs, sockets, devices…).
- Suffix: the file name must end in
.sh.
If you write a hook, ensure your packaging installs it owned by root with mode 0755, e.g. install -m 0755 -o root -g root your-hook.sh /etc/pxf/post-apply.d/your-hook.sh.
Idempotency and execution order
pxf apply flushes the netfilter tables before re-applying rules from the journal, so when your hook runs the relevant tables are already at a clean baseline. You can insert rules without first checking for duplicates.
There is one important caveat for the nftables-native backend: nft flush ruleset drops every table, including ip nat / ip6 nat. If pxf has no redirects of its own in the journal, those tables will not exist when your hook runs. Hooks that touch nat should therefore bootstrap the table and chain themselves (see the example below). On the iptables backends (iptables-legacy / iptables-nft) the nat table and PREROUTING chain are built-in and survive a flush, so no bootstrap is required there.
Hooks run sequentially, in alphabetical order of file name. There is no parallelism. Order between unrelated packages should be controlled with the NN- prefix convention.
Error handling
A failing hook (non-zero exit, timeout, validation failure) does not abort the surrounding pxf operation. The error is logged with the hook path, exit code and elapsed time, and pxf moves on. Hooks therefore should fail loudly (exit non-zero with a meaningful message) rather than swallow errors silently.
The default per-hook timeout is 30 seconds.
Example: NAT redirect hook
The skeleton below is a generic template you can adapt for any service that needs a port redirect or extra rule kept in place across pxf apply / pxf disable. It demonstrates the patterns you should follow:
set -eso real failures surface (pxf logs the exit code).- A guard at the top so the hook becomes a no-op when its dependent service is not running — replace
your-service.servicewith whateversystemdunit owns the rules. If your hook does not depend on a service, drop the guard. - A
case "$PXF_BACKEND"switch with branches for nftables and iptables. The empty / unknown case exits cleanly. - On the nftables branch, bootstrap the
nattable andPREROUTINGchain (with priority-100, the canonical NAT priority). Afterpxf applyon a journal without redirects,nft flush rulesethas dropped everything; if you skip the bootstrap yournft insert rulewill fail. - IPv4 lives under
set -e; IPv6 is wrapped withset +eso containers/kernels withoutnf_nat_ipv6do not break IPv4 protection.
Replace <EXTERNAL_PORT> and <INTERNAL_PORT> with the values your service needs (for example, 80 → 8080).
#!/usr/bin/env bash
set -e
# Optional: only act when the dependent service is up. Drop this guard if
# your hook is unconditional.
systemctl -q is-active your-service.service || exit 0
case "$PXF_BACKEND" in
nftables)
# Bootstrap IPv4 nat infrastructure — pxf may have flushed the whole ruleset.
nft list table ip nat >/dev/null 2>&1 \
|| nft add table ip nat
nft list chain ip nat PREROUTING >/dev/null 2>&1 \
|| nft add chain ip nat PREROUTING '{ type nat hook prerouting priority -100 ; }'
nft insert rule ip nat PREROUTING tcp dport <EXTERNAL_PORT> counter redirect to :<INTERNAL_PORT>
# IPv6 best-effort (some kernels/containers lack nf_nat_ipv6).
set +e
nft list table ip6 nat >/dev/null 2>&1 \
|| nft add table ip6 nat
nft list chain ip6 nat PREROUTING >/dev/null 2>&1 \
|| nft add chain ip6 nat PREROUTING '{ type nat hook prerouting priority -100 ; }'
nft insert rule ip6 nat PREROUTING tcp dport <EXTERNAL_PORT> counter redirect to :<INTERNAL_PORT>
;;
iptables-legacy|iptables-nft)
iptables -t nat -I PREROUTING -p tcp --dport <EXTERNAL_PORT> -j REDIRECT --to-ports <INTERNAL_PORT>
set +e
ip6tables -t nat -I PREROUTING -p tcp --dport <EXTERNAL_PORT> -j REDIRECT --to-ports <INTERNAL_PORT>
;;
*)
# Unknown / empty backend — do nothing rather than guess.
exit 0
;;
esac
exit 0
A real-world consumer of this pattern is pxShield, which ships a hook of its own that redirects 80 → 19080 and 443 → 19443 so HTTP/HTTPS traffic transparently flows through the WAF. The hook is installed automatically by pxShield's package — no manual setup needed.
Skipping hooks
You can disable hook execution per-invocation with --no-hooks:
pxf apply --no-hooks # apply without running post-apply hooks
pxf disable --no-hooks # absolute killswitch — flush + ALLOW ALL, no hooks
--no-hooks is an emergency lever. The expected use is troubleshooting (e.g. ruling out a hook as a cause of a problem) or an absolute killswitch when responding to an incident. Routine operation should leave hooks enabled.
Packaging integration: handling install order
Hooks living under /etc/pxf/post-apply.d/ only fire when pxf apply runs. That means an integrator package needs to make sure (a) its hook is present in the directory at the moment pxf applies, and (b) pxf apply is invoked at least once after the hook is installed. Two install orders matter:
1. Integrator installed first, pxf installed afterwards. The integrator's postinstall cannot drop a hook because /etc/pxf/post-apply.d/ does not exist yet. When pxf is later installed, pxf knows nothing about the integrator and will not run the hook automatically. The recommended fix is an RPM trigger in the integrator package that fires when pxf gets installed. The pxf RPM package name is pyxsoft-pxf:
%triggerin -- pyxsoft-pxf
# Or, with fpm: --rpm-trigger-after-install 'pyxsoft-pxf:trigger-pxf-installed.sh'
set -eu
HOOK_SRC=/opt/your-service/scripts/50-your-service.sh
[ -x /usr/sbin/pxf ] || exit 0
[ -f "$HOOK_SRC" ] || exit 0
install -d -m 0755 -o root -g root /etc/pxf/post-apply.d
install -d -m 0755 -o root -g root /etc/pxf/post-disable.d
install -m 0755 -o root -g root "$HOOK_SRC" /etc/pxf/post-apply.d/50-your-service.sh
install -m 0755 -o root -g root "$HOOK_SRC" /etc/pxf/post-disable.d/50-your-service.sh
# Reconcile kernel state only if pxf is enabled. If disabled, the hook is
# pre-positioned and will run on the next 'pxf enable'.
if pxf is-enabled --quiet; then
pxf apply || logger -t your-service-trigger "pxf apply failed during trigger"
fi
Naming note: the trigger target (
pyxsoft-pxf) is the RPM package name, while the binary invoked at runtime ispxf(installed as/usr/sbin/pxf). Usepyxsoft-pxfonly in the%triggerin/ fpm flag; everywhere else (script bodies,command -v,is-enabled, etc.) keep usingpxf.
The script must be idempotent: RPM triggers fire on both install and upgrade, and may fire when either side of the relationship changes. Use install -m 0755 (which overwrites) rather than cp, and never accumulate state.
2. pxf installed first, integrator installed afterwards. This is the easy case: the integrator's normal postinstall (or service ExecStartPost) drops the hook and runs pxf apply directly. No trigger is required, but the same idempotency rules apply.
Checking pxf state from scripts
Integrator scripts that need to know whether pxf is enabled should call pxf is-enabled --quiet:
if pxf is-enabled --quiet; then
pxf apply
else
echo "pxf is disabled; the hook is in place but will not run until 'pxf enable'." >&2
fi
pxf is-enabled exits 0 when enabled and 1 when disabled, with no output in --quiet mode. This is the supported contract for scripts; do not parse pxf status --json from postinstall scriptlets, as the JSON shape is not part of the script-stable API.
Cleanup on uninstall
When the integrator is uninstalled, its preremove (or postremove) should:
- Remove its hook script from
/etc/pxf/post-apply.d/and/etc/pxf/post-disable.d/. - Run
pxf applyif pxf is enabled, so the kernel reconciles to the new (no-hook) state.
Symmetrically, an RPM %triggerpostun -- pyxsoft-pxf lets the integrator reapply its rules directly when pxf itself is uninstalled (since pxf's preremove will flush the kernel). See pxShield's packaging for a worked example.
Authoring checklist
- Filename matches
NN-<vendor>.shwith executable bit set. - Owned by
root:root, mode0755, not a symlink. - Branches on
PXF_BACKENDand treats empty as "no-op". - Idempotent (assumes a flushed table at start; uses
insert/-I). - Fails loudly on real errors; uses
|| trueor2>/dev/nullonly for known-benign cases (e.g. missing IPv6 module). - Cleans up on uninstall: the package's
%preun(or systemdExecStop) removes the hook from bothpost-apply.d/andpost-disable.d/and triggers a freshpxf applyto reconcile the kernel.